package edu.washington.cs.sounddetector.server.c2dm;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import org.apache.commons.lang3.Validate;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.ParseException;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;

/**
 * Class for sending push notifications using <a
 * href="http://code.google.com/android/c2dm/index.html">Android Cloud to Device Messaging</a>.
 * 
 * @author the.dylan.price@gmail.com
 * 
 */
public class C2DMessaging
{
    private static final Log log = LogFactory.getLog(C2DMessaging.class);

    private static final String C2DM_SEND_ENDPOINT = "https://android.clients.google.com/c2dm/send";

    private static final String PARAM_REGISTRATION_ID = "registration_id";
    private static final String PARAM_DELAY_WHILE_IDLE = "delay_while_idle";
    private static final String PARAM_COLLAPSE_KEY = "collapse_key";
    private static final String PARAM_DATA = "data.";

    private static final String HEADER_AUTHORIZATION = "Authorization";
    private static final String HEADER_RETRY_AFTER = "Retry-After";
    private static final String HEADER_UPDATE_CLIENT_AUTH = "Update-Client-Auth";

    private ClientConnectionManager clientConnManager;
    private ClientAuthToken authToken;
    private RegistrationIdRemover regIdRemover;
    private int retryBackoffSeconds;
    private int retryCountThreshold;

    /**
     * Construct a new C2DMessaging.
     * 
     * @param clientConnManager
     *            the connection manager to use in HttpClients
     * @param authToken
     *            the ClientAuthToken containing the <a
     *            href="http://code.google.com/apis/accounts/docs/AuthForInstalledApps.html">Google
     *            ClientLogin</a> auth token.
     * @param regIdRemover
     *            on certain errors, registration ids need to be removed from the third party
     *            application server. This specifies how that should be done.
     */
    public C2DMessaging(ClientConnectionManager clientConnManager, ClientAuthToken authToken,
            RegistrationIdRemover regIdRemover)
    {
        Validate.notNull(clientConnManager);
        Validate.notNull(authToken);
        Validate.notNull(regIdRemover);

        this.clientConnManager = clientConnManager;
        this.authToken = authToken;
        this.regIdRemover = regIdRemover;
        this.retryBackoffSeconds = 30;
        this.retryCountThreshold = 10;
    }

    /**
     * <p>
     * Sends a push notification to the phone with the given registrationId. Automatically handles
     * retrying logic and backoff. If an error occurs and the given registration id is no longer
     * valid, this {@link C2DMessaging}'s {@link RegistrationIdRemover} will be called.
     * </p>
     * 
     * <p>
     * Note that this is a blocking call, including retries.
     * </p>
     * 
     * @param registrationId
     *            the registration id
     * @param data
     *            a set of key value pairs to send to the phone
     * @return true if the push succeeded, false if not. If returns false, check the logs for
     *         details.
     */
    public boolean sendPush(String registrationId, Map<String, String> data)
    {
        return sendPush(registrationId, data, 0);
    }

    private boolean sendPush(String registrationId, Map<String, String> data, int retryCount)
    {
        Validate.notBlank(registrationId);
        Validate.noNullElements(data.keySet());
        Validate.isTrue(retryCount >= 0);

        data = new HashMap<String, String>(data);

        if (retryCount > retryCountThreshold)
            return false;

        HttpClient client = new DefaultHttpClient(clientConnManager);
        HttpPost post = createPushPost(registrationId, data, authToken.getAuthToken());
        try
        {
            HttpResponse resp = client.execute(post);

            // check Update-Client-Auth
            Header updateClientAuth = resp.getFirstHeader(HEADER_UPDATE_CLIENT_AUTH);
            if (updateClientAuth != null)
            {
                authToken.setAuthToken(updateClientAuth.getValue());
            }

            switch (resp.getStatusLine().getStatusCode())
            {
            case 200:
                return checkOkResponse(resp, registrationId, data, retryCount);
            case 503:
                retryCount++;
                int retryAfterSeconds = calculateRetry(retryCount);

                // check Retry-After header
                Header retryAfter = resp.getFirstHeader(HEADER_RETRY_AFTER);
                if (retryAfter != null)
                    retryAfterSeconds = Integer.parseInt(retryAfter.getValue());

                return retry(registrationId, data, retryCount, retryAfterSeconds);
            case 401:
                log.fatal("AuthToken invalid: " + authToken);
                return false;
            default:
                return false;
            }
        }
        catch (ClientProtocolException e)
        {
            log.error("ClientProtocolException", e);
            return false;
        }
        catch (IOException e)
        {
            log.error("IOException", e);
            return false;
        }
    }

    private HttpPost createPushPost(String registrationId, Map<String, String> data,
            String authToken)
    {
        try
        {
            List<NameValuePair> formparams = new ArrayList<NameValuePair>();

            formparams.add(new BasicNameValuePair(PARAM_REGISTRATION_ID, registrationId));
            formparams.add(new BasicNameValuePair(PARAM_COLLAPSE_KEY, "collapseKey"));
            for (Entry<String, String> entry : data.entrySet())
            {
                formparams
                        .add(new BasicNameValuePair(PARAM_DATA + entry.getKey(), entry.getValue()));
            }

            UrlEncodedFormEntity entity = new UrlEncodedFormEntity(formparams, "UTF-8");

            HttpPost post = new HttpPost(C2DM_SEND_ENDPOINT);
            post.setEntity(entity);
            post.setHeader(HEADER_AUTHORIZATION, "GoogleLogin auth=" + authToken);

            return post;
        }
        catch (UnsupportedEncodingException e)
        {
            // should not happen
            log.fatal("UnsupportedEncodingException", e);
            throw new RuntimeException(e);
        }
    }

    private boolean checkOkResponse(HttpResponse resp, String registrationId,
            Map<String, String> data, int retryCount)
    {
        try
        {
            HttpEntity entity = resp.getEntity();
            String respData = EntityUtils.toString(entity);
            if (respData.startsWith("id"))
            {
                // everything OK! 
                return true;
            }
            else if (respData.startsWith("Error"))
            {
                String error = respData.substring(6);
                return handleError(error, registrationId, data, retryCount);
            }
            else
            {
                String message = "Unkown response parameter: " + respData;
                log.error(message);
                return false;
            }
        }
        catch (ParseException e)
        {
            log.error("ParseException", e);
            return false;
        }
        catch (IOException e)
        {
            log.error("IOException", e);
            return false;
        }
    }

    private boolean handleError(String error, String registrationId, Map<String, String> data,
            int retryCount)
    {
        retryCount++;
        int retryAfterSeconds = calculateRetry(retryCount);

        if (error.equals("QuotaExceeded"))
        {
            // Too many messages sent by the sender. Retry after a while.
            return retry(registrationId, data, retryCount, retryAfterSeconds);
        }
        else if (error.equals("DeviceQuotaExceeded"))
        {
            // Too many messages sent by the sender to a specific device. Retry after a while.
            return retry(registrationId, data, retryCount, retryAfterSeconds);
        }
        else if (error.equals("MissingRegistration"))
        {
            // Missing registration_id. Sender should always add the registration_id to the request.
            String message = "MissingRegistration: did not include registration_id in request.";
            log.fatal(message);
            throw new RuntimeException(message);
        }
        else if (error.equals("InvalidRegistration"))
        {
            // Bad registration_id. Sender should remove this registration_id.
            regIdRemover.removeRegistrationId(registrationId);
            return false;
        }
        else if (error.equals("MismatchSenderId"))
        {
            // The sender_id contained in the registration_id does not match the sender id used to register with the C2DM servers.
            regIdRemover.removeRegistrationId(registrationId);
            return false;
        }
        else if (error.equals("NotRegistered"))
        {
            // The user has uninstalled the application or turned off notifications. Sender should stop sending messages to this device and delete the registration_id. The client needs to re-register with the c2dm servers to receive notifications again.
            regIdRemover.removeRegistrationId(registrationId);
            return false;
        }
        else if (error.equals("MessageTooBig"))
        {
            // The payload of the message is too big, see the limitations. Reduce the size of the message.
            String message = "MessageTooBig: payload of message is too big";
            log.error(message);
            data.clear();
            return retry(registrationId, data, retryCount, retryAfterSeconds);
        }
        else if (error.equals("MissingCollapseKey"))
        {
            // Collapse key is required. Include collapse key in the request.
            String message = "MissingCollapseKey: did not include collapse_key in request";
            log.fatal(message);
            throw new RuntimeException(message);
        }
        else
        {
            String message = "Unknown error response: " + error;
            log.fatal(message);
            throw new RuntimeException(message);
        }
    }

    private boolean retry(String registrationId, Map<String, String> data, int retryCount,
            int retryAfterSeconds)
    {
        try
        {
            Thread.sleep(retryAfterSeconds * 1000);
            return sendPush(registrationId, data, retryCount);
        }
        catch (InterruptedException e)
        {
            log.error("InterruptedException", e);
            return false;
        }
    }

    private int calculateRetry(int retryCount)
    {
        return (retryCount * retryCount) * retryBackoffSeconds;
    }

    /**
     * @return the regIdRemover
     */
    public RegistrationIdRemover getRegIdRemover()
    {
        return regIdRemover;
    }

    /**
     * @param regIdRemover
     *            the regIdRemover to set
     */
    public void setRegIdRemover(RegistrationIdRemover regIdRemover)
    {
        this.regIdRemover = regIdRemover;
    }

    /**
     * @return the authToken
     */
    public ClientAuthToken getAuthToken()
    {
        return authToken;
    }

    /**
     * @param authToken
     *            the authToken to set
     */
    public void setAuthToken(ClientAuthToken authToken)
    {
        this.authToken = authToken;
    }

    /**
     * @return the clientConnManager
     */
    public ClientConnectionManager getClientConnManager()
    {
        return clientConnManager;
    }

    /**
     * @param clientConnManager
     *            the clientConnManager to set
     */
    public void setClientConnManager(ClientConnectionManager clientConnManager)
    {
        this.clientConnManager = clientConnManager;
    }

    /**
     * @return the retryBackoffSeconds
     */
    public int getRetryBackoffSeconds()
    {
        return retryBackoffSeconds;
    }

    /**
     * @param retryBackoffSeconds
     *            the retryBackoffSeconds to set
     */
    public void setRetryBackoffSeconds(int retryBackoffSeconds)
    {
        this.retryBackoffSeconds = retryBackoffSeconds;
    }

    /**
     * @return the retryCountThreshold
     */
    public int getRetryCountThreshold()
    {
        return retryCountThreshold;
    }

    /**
     * @param retryCountThreshold
     *            the retryCountThreshold to set
     */
    public void setRetryCountThreshold(int retryCountThreshold)
    {
        this.retryCountThreshold = retryCountThreshold;
    }

}
